Skip to main content

webx_host/
server.rs

1//! Host builder and hyper server integration.
2//!
3//! Includes built-in exception middleware: errors produced by endpoints
4//! are caught and converted to well-formed HTTP error responses using
5//! `Error::status_code()`.
6
7use 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};
22
23use crate::auth_jwt::{init_jwt_secret, jwt_middleware, JwtAuth};
24use crate::authz::collect_authorizers;
25use crate::context::{BodyLimits, HttpContext, RespBody};
26use crate::cors::{CorsConfig, CorsMiddleware};
27use crate::endpoint::{StaticHtmlEndpoint, StaticJsonEndpoint, StubEndpoint};
28use crate::health::{HealthCheckEndpoint, HealthCheckRegistry, HealthStatus};
29use crate::memory_cache::MemoryCache;
30use crate::metrics::{HttpMetrics, MetricsEndpoint, MetricsMiddleware};
31use crate::pipeline::{HandlerFn, MiddlewarePipeline};
32use crate::problem_response::{build_problem, write_problem_response, write_unauthorized};
33use crate::rate_limit::RateLimitMiddleware;
34use crate::router::Router;
35use jsonwebtoken::{DecodingKey, Validation};
36use webx_core::route::scan::RouteEntry;
37use webx_core::DispatchRuntime;
38use webx_openapi::{generate_openapi_spec, APIUI_HTML};
39use webx_spa::{EmbeddedAssets, SpaMiddleware, SpaSource};
40
41use rustls::pki_types::{CertificateDer, PrivateKeyDer};
42use rustls::ServerConfig;
43use rustls_pemfile::{certs, pkcs8_private_keys, rsa_private_keys};
44use std::sync::Arc;
45use std::time::Instant;
46use tokio::task::JoinSet;
47use tokio_rustls::TlsAcceptor;
48
49/// Returns true when the JWT secret is missing, too short, or a known placeholder.
50fn is_weak_jwt_secret(secret: &str) -> bool {
51    secret.is_empty()
52        || secret.len() < 32
53        || secret.contains("change-in-production")
54        || secret.contains("please-change")
55        || secret.contains("demo-secret")
56        || secret.contains("dev-secret")
57}
58
59/// Log where the SPA files come from and which embedded files are overridden.
60///
61/// Overrides are the point of the feature, but a stale one silently defeats an
62/// updated build, so the list is logged instead of waiting to be discovered.
63fn log_spa_source(spa: &SpaMiddleware) {
64    tracing::info!("[Host] SPA root: {}", spa.resolved_root().display());
65    let Some(assets) = spa.embedded() else {
66        return;
67    };
68    tracing::info!(
69        "[Host] Embedded assets: {} files, {:.1} MiB embedded ({:.1} MiB uncompressed), built from `{}`",
70        assets.len(),
71        assets.total_bytes() as f64 / (1024.0 * 1024.0),
72        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
73        assets.root(),
74    );
75    let shadowed = assets.shadowed_by(spa.resolved_root());
76    if !shadowed.is_empty() {
77        tracing::warn!(
78            "[Host] {} embedded file(s) are overridden on disk: {}. \
79             An override older than the current build stays in effect until it is \
80             updated or deleted.",
81            shadowed.len(),
82            shadowed.join(", "),
83        );
84    }
85}
86
87/// Layer a compiled-in table under `source` when one was registered.
88///
89/// Opt-in is `#[webx::main(embed)]` (plus `build.rs` `web_root`).
90/// `use_spa` names the disk overlay; without it, the table's build-time root
91/// is used as the overlay path.
92fn with_embedded_assets(
93    source: Option<SpaSource>,
94    assets: Option<EmbeddedAssets>,
95) -> Option<SpaSource> {
96    let Some(assets) = assets else {
97        return source;
98    };
99    let overlay_root = match source.as_ref() {
100        Some(existing) => existing.disk_root().to_string(),
101        None => assets.root().to_string(),
102    };
103    Some(SpaSource::Embedded {
104        assets,
105        overlay_root,
106    })
107}
108
109/// Shutdown signal shared by the host, its handle and every listener.
110///
111/// `watch` (not `Notify`) because a signal must not be lost: `Notify::notify_waiters`
112/// drops the wakeup when no listener is parked yet, which is exactly the case in
113/// the documented "spawn(run) then handle.shutdown()" pattern. A `watch` keeps
114/// the `true` value, so a listener that subscribes later still sees it.
115type ShutdownSignal = Arc<tokio::sync::watch::Sender<bool>>;
116
117pub struct Host {
118    #[allow(dead_code)]
119    provider: Arc<ServiceProvider>,
120    dispatch_runtime: Arc<DispatchRuntime>,
121    pub options: AppOptions,
122    /// Endpoint metadata for this host, built once from the compile-time routes.
123    endpoints: Arc<dyn IEndpointCatalog>,
124    /// Resolves endpoint resource keys for the host's configured culture.
125    localizer: Arc<dyn IStringLocalizer>,
126    /// Body and upload limits, derived from `App` + `Form` configuration.
127    body_limits: BodyLimits,
128    pipeline: Arc<MiddlewarePipeline>,
129    /// The matchit router (retained for introspection).
130    #[allow(dead_code)]
131    router: Arc<Router>,
132    /// Pre-built router handler —eliminates per-request Arc::new.
133    router_handler: HandlerFn,
134    mode: AppMode,
135    /// Kept for introspection; the middleware already holds its own copy.
136    #[allow(dead_code)]
137    spa_source: Option<SpaSource>,
138    shutdown: ShutdownSignal,
139    /// Hosted services that are started on host start
140    /// and stopped on graceful shutdown.
141    hosted_services: Vec<Arc<dyn IHostedService>>,
142}
143
144/// Factory for a user-registered middleware.
145type MiddlewareFactory = Box<dyn Fn() -> Arc<dyn IMiddleware> + Send + Sync>;
146
147/// A user middleware waiting to be placed in the pipeline at its slot.
148struct MiddlewareSpec {
149    slot: webx_core::middleware::MiddlewareSlot,
150    factory: MiddlewareFactory,
151}
152
153#[allow(clippy::type_complexity)]
154pub struct HostBuilder {
155    service_configs: Vec<Box<dyn FnOnce(ServiceCollection) -> ServiceCollection + Send>>,
156    mode: AppMode,
157    spa_source: Option<SpaSource>,
158    spa_disabled: bool,
159    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
160    cors_config: Option<CorsConfig>,
161    auth_enabled: bool,
162    localization: Option<LocalizationOptions>,
163    health_registry: Arc<HealthCheckRegistry>,
164    /// Middlewares registered through the builder, kept with their slot so
165    /// ordering does not depend on DI enumeration order.
166    middleware_specs: Vec<MiddlewareSpec>,
167    /// Opt back into the pre-1.0 lenient root-scope behavior (rust-dix 0.7).
168    /// Migration-only: 1.0 defaults to strict scope validation.
169    lenient_scope_validation: bool,
170}
171
172/// Everything `prepare` collects before the DI provider is built.
173///
174/// Splitting at the provider boundary is what lets `try_build` and
175/// `try_build_async` share the whole assembly (options, host defaults, pipeline,
176/// router) while choosing rust-dix's sync or async provider builder.
177struct PreparedHost {
178    builder: HostBuilder,
179    svc: ServiceCollection,
180    provider_options: rust_dix::ServiceProviderOptions,
181    options: AppOptions,
182    endpoints: Arc<dyn IEndpointCatalog>,
183    localizer: Arc<dyn IStringLocalizer>,
184    handler_cache: Arc<webx_core::route::scan::HandlerCache>,
185}
186
187#[allow(clippy::type_complexity)]
188pub struct HostAppBuilder {
189    options_modifiers: Vec<Box<dyn FnOnce(&mut AppOptions) + Send>>,
190}
191
192impl HostAppBuilder {
193    fn new() -> Self {
194        Self {
195            options_modifiers: Vec::new(),
196        }
197    }
198
199    #[allow(non_snake_case)]
200    pub fn useOptions<F>(&mut self, f: F)
201    where
202        F: FnOnce(&mut AppOptions) + Send + 'static,
203    {
204        self.options_modifiers.push(Box::new(f));
205    }
206}
207
208impl HostBuilder {
209    pub fn new() -> Self {
210        Self {
211            service_configs: Vec::new(),
212            // 默认从 `APP_ENV` 环境变量解析运行模式;未设置时为 Production。
213            // 应用可通过 `.mode()` 显式覆盖(本机开发通常设 APP_ENV=Development)。
214            mode: AppMode::from_env(),
215            spa_source: None,
216            spa_disabled: false,
217            options_modifiers: Vec::new(),
218            cors_config: None,
219            auth_enabled: false,
220            localization: None,
221            health_registry: HealthCheckRegistry::new(),
222            middleware_specs: Vec::new(),
223            lenient_scope_validation: false,
224        }
225    }
226
227    pub fn register<F>(mut self, f: F) -> Self
228    where
229        F: FnOnce(ServiceCollection) -> ServiceCollection + Send + 'static,
230    {
231        self.service_configs.push(Box::new(f));
232        self
233    }
234
235    pub fn configure<F>(mut self, f: F) -> Self
236    where
237        F: FnOnce(&mut HostAppBuilder) + Send + 'static,
238    {
239        let mut builder = HostAppBuilder::new();
240        f(&mut builder);
241        self.options_modifiers
242            .append(&mut builder.options_modifiers);
243        self
244    }
245
246    pub fn mode(mut self, mode: AppMode) -> Self {
247        self.mode = mode;
248        self
249    }
250
251    /// Keep the pre-1.0 (rust-dix 0.7) lenient root-scope behavior.
252    ///
253    /// 1.0 defaults to **strict** scope validation (MEDI `ValidateScopes = true`):
254    /// resolving a Scoped service from the root `ServiceProvider` returns
255    /// [`webx::Error::Di`](crate::Error) instead of silently caching it on the
256    /// root. Existing code that resolves Scoped services from
257    /// `host.provider()` can opt back in here while it migrates to explicit
258    /// scopes (`provider.scope()` / `get_owned`) — see
259    /// `docs/rust-webx/16-migration/upgrade-to-1.0.md`.
260    pub fn lenient_scope_validation(mut self) -> Self {
261        self.lenient_scope_validation = true;
262        self
263    }
264
265    /// 自动绑定 appsettings 配置节到 `T`,并以 `Arc<T>` 注册到 DI 容器。
266    ///
267    /// 参考 ASP.NET Core 的 `services.Configure<T>(configuration.GetSection(...))`:
268    /// 框架在 `build()` 时读取已合并的 appsettings(含 `{Mode}` overlay 与 env override),
269    /// 取 `section` 子节点反序列化为 `T`,注册为单例供 handler 通过 `Arc<T>` 注入。
270    ///
271    /// ```ignore
272    /// Host::builder()
273    ///     .add_options::<SiteConfig>("Site")
274    ///     .build()
275    /// ```
276    pub fn add_options<T>(self, section: &str) -> Self
277    where
278        T: for<'de> serde::Deserialize<'de> + Default + Send + Sync + 'static,
279    {
280        let mode = self.mode;
281        let section = section.to_string();
282        self.register(move |svc| {
283            let appsettings = config::load_appsettings(mode).unwrap_or_default();
284            let bound: T = config::bind_config(&appsettings, &section);
285            tracing::info!(
286                "[Host] add_options<{}>(\"{}\") registered",
287                std::any::type_name::<T>(),
288                section
289            );
290            svc.instance(Arc::new(bound))
291        })
292    }
293
294    /// Serve SPA static files from a directory on disk.
295    ///
296    /// ```ignore
297    /// Host::builder().use_spa("wwwroot").build()
298    /// ```
299    ///
300    /// **Runtime disk root** — the directory next to the process (or under
301    /// `app_base`) where operators may drop overrides. This is not
302    /// `build.rs` `web_root` (that path only names what was baked in).
303    ///
304    /// When the binary was built with `#[webx::main(embed)]` (and
305    /// `build.rs` wrote a table), that table is layered underneath this
306    /// directory at [`build`](Self::build): same-named disk files win.
307    /// Without `(embed)`, only the disk directory is served.
308    pub fn use_spa(mut self, source: impl Into<SpaSource>) -> Self {
309        self.spa_source = Some(match (self.spa_source.take(), source.into()) {
310            // Compiled-in files are already configured: only move the directory
311            // overrides are read from, rather than dropping the baseline.
312            (Some(SpaSource::Embedded { assets, .. }), SpaSource::Disk(root)) => {
313                SpaSource::Embedded {
314                    assets,
315                    overlay_root: root,
316                }
317            }
318            (_, incoming) => incoming,
319        });
320        self
321    }
322
323    /// Explicitly disable SPA, including auto-detection.
324    ///
325    /// By default, the framework auto-detects `wwwroot/` under `app_base()`.
326    /// Call this when you want a pure API host without SPA fallback,
327    /// or in tests to isolate framework behavior from filesystem state.
328    pub fn no_spa(mut self) -> Self {
329        self.spa_disabled = true;
330        self
331    }
332
333    pub fn use_cors(mut self, config: CorsConfig) -> Self {
334        self.cors_config = Some(config);
335        self
336    }
337
338    /// Register the JWT authentication service and middleware.
339    ///
340    /// The ASP.NET Core `AddAuthentication()` + `UseAuthentication()` pair: it
341    /// enables JWT bearer authentication so endpoints that declare
342    /// `#[authorize]` receive claims. Route authorization itself is part of each
343    /// endpoint's compile-time metadata and is always enforced after routing —
344    /// there is no second switch to turn it on.
345    ///
346    /// ```ignore
347    /// Host::builder()
348    ///     .add_authentication()
349    ///     .build()
350    /// ```
351    pub fn add_authentication(mut self) -> Self {
352        self.auth_enabled = true;
353        self
354    }
355
356    /// Enable localization with the conventional resource layout.
357    ///
358    /// Reads `<culture>.json` files from `locales/` under the application base
359    /// (for example `locales/zh-CN.json`, `locales/en.json`). The active culture
360    /// is `Culture.Default` from appsettings (or `APP__Culture__Default`); it
361    /// decides the language of endpoint names, OpenAPI, and the endpoint
362    /// catalog.
363    ///
364    /// ```ignore
365    /// Host::builder().add_localization().build()
366    /// ```
367    ///
368    /// A missing `locales/` directory is not an error; keys then fall back to
369    /// their literal or the key itself. For a single-file deployment, register
370    /// compiled-in resources with [`Self::add_localization_with`].
371    pub fn add_localization(mut self) -> Self {
372        self.localization = Some(LocalizationOptions::new());
373        self
374    }
375
376    /// Enable localization with explicit options.
377    ///
378    /// Use this for compiled-in resources (`include_str!`) or a non-conventional
379    /// resource directory. Explicit resources override files with the same
380    /// culture and key.
381    ///
382    /// ```ignore
383    /// Host::builder()
384    ///     .add_localization_with(
385    ///         LocalizationOptions::none()
386    ///             .resources("zh-CN", include_str!("../locales/zh-CN.json"))
387    ///             .resources("en", include_str!("../locales/en.json")),
388    ///     )
389    ///     .build()
390    /// ```
391    pub fn add_localization_with(mut self, options: LocalizationOptions) -> Self {
392        self.localization = Some(options);
393        self
394    }
395
396    /// Register a memory cache instance for use via `IDistributedCache` trait.
397    ///
398    /// The cache is registered as a singleton in the DI container.
399    /// Handlers can access it by implementing `From<Arc<MemoryCache>>`.
400    ///
401    /// ```ignore
402    /// Host::builder()
403    ///     .add_memory_cache()
404    ///     .build()
405    ///     .run().await;
406    /// ```
407    pub fn add_memory_cache(mut self) -> Self {
408        let cache = Arc::new(MemoryCache::new());
409        self.service_configs
410            .push(Box::new(move |svc| svc.instance(cache)));
411        self
412    }
413
414    /// Register a memory cache with custom configuration.
415    pub fn add_memory_cache_with(mut self, max_entries: usize) -> Self {
416        let cache = Arc::new(MemoryCache::new().with_max_entries(max_entries));
417        self.service_configs
418            .push(Box::new(move |svc| svc.instance(Arc::clone(&cache))));
419        self
420    }
421
422    /// Register a middleware by type in the default slot
423    /// ([`MiddlewareSlot::AfterAuth`](webx_core::middleware::MiddlewareSlot::AfterAuth)).
424    ///
425    /// The default slot runs **after** authentication, so middleware that reads
426    /// `ctx.claims()` actually sees the principal. Use
427    /// [`Self::use_middleware_at`] to choose a different slot.
428    ///
429    /// ```ignore
430    /// Host::builder()
431    ///     .use_middleware::<TimingMiddleware>()
432    ///     .build()
433    /// ```
434    pub fn use_middleware<T: IMiddleware + Default + 'static>(self) -> Self {
435        self.use_middleware_at::<T>(webx_core::middleware::MiddlewareSlot::AfterAuth)
436    }
437
438    /// Register a middleware by type in a specific pipeline slot.
439    pub fn use_middleware_at<T: IMiddleware + Default + 'static>(
440        mut self,
441        slot: webx_core::middleware::MiddlewareSlot,
442    ) -> Self {
443        self.middleware_specs.push(MiddlewareSpec {
444            slot,
445            factory: Box::new(|| Arc::new(T::default()) as Arc<dyn IMiddleware>),
446        });
447        self
448    }
449
450    /// Register a middleware with a custom factory function (default slot:
451    /// [`MiddlewareSlot::AfterAuth`](webx_core::middleware::MiddlewareSlot::AfterAuth)).
452    ///
453    /// Use this when the middleware requires constructor parameters:
454    ///
455    /// ```ignore
456    /// Host::builder()
457    ///     .use_middleware_with(|| {
458    ///         Arc::new(TimingMiddleware::new("api")) as Arc<dyn IMiddleware>
459    ///     })
460    ///     .build()
461    /// ```
462    pub fn use_middleware_with<F>(self, factory: F) -> Self
463    where
464        F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
465    {
466        self.use_middleware_with_at(webx_core::middleware::MiddlewareSlot::AfterAuth, factory)
467    }
468
469    /// Register a middleware with a custom factory in a specific pipeline slot.
470    pub fn use_middleware_with_at<F>(
471        mut self,
472        slot: webx_core::middleware::MiddlewareSlot,
473        factory: F,
474    ) -> Self
475    where
476        F: Fn() -> Arc<dyn IMiddleware> + Send + Sync + 'static,
477    {
478        self.middleware_specs.push(MiddlewareSpec {
479            slot,
480            factory: Box::new(factory),
481        });
482        self
483    }
484
485    /// Register a health check probe.
486    ///
487    /// Health checks are exposed via `/health` and `/health/ready` endpoints
488    /// following RFC 8407 (`application/health+json` content type).
489    ///
490    /// ```ignore
491    /// Host::builder()
492    ///     .add_health_check("database", || -> HealthStatus {
493    ///         if db_ping() { HealthStatus::pass() } else { HealthStatus::fail("db unreachable") }
494    ///     })
495    ///     .build()
496    /// ```
497    pub fn add_health_check<F>(self, name: impl Into<String>, check: F) -> Self
498    where
499        F: Fn() -> HealthStatus + Send + Sync + 'static,
500    {
501        self.health_registry.register(name, Arc::new(check));
502        self
503    }
504
505    /// Build the host, reporting configuration problems as `Err` instead of
506    /// panicking.
507    ///
508    /// This is the non-panicking form of [`HostBuilder::build`]. Production
509    /// guards (missing/weak JWT secret, wildcard CORS), a failing DI provider
510    /// build, invalid localization resources and inconsistent route/handler
511    /// metadata all come back as [`Error::Di`] carrying the same operator-facing
512    /// message [`HostBuilder::build`] panics with.
513    ///
514    /// ```ignore
515    /// match Host::builder().mode(AppMode::Production).try_build() {
516    ///     Ok(host) => host.run().await?,
517    ///     Err(error) => eprintln!("invalid configuration: {error}"),
518    /// }
519    /// ```
520    pub fn try_build(self) -> Result<Host> {
521        let prepared = self.prepare()?;
522        let PreparedHost {
523            builder,
524            svc,
525            provider_options,
526            options,
527            endpoints,
528            localizer,
529            handler_cache,
530        } = prepared;
531        let provider = svc
532            .build_with(provider_options)
533            .map_err(build_provider_error)?;
534        builder.assemble(provider, handler_cache, options, endpoints, localizer)
535    }
536
537    /// Build the host, awaiting rust-dix's `build_async` for async singletons.
538    ///
539    /// Use this instead of [`HostBuilder::try_build`] when the service
540    /// collection contains `async_*` registrations (for example rust-ef's
541    /// `add_dbcontext_async`). Async singletons are initialised dependency-first
542    /// by rust-dix before the host is assembled; failures surface as `Error::Di`.
543    ///
544    /// ```ignore
545    /// let host = Host::builder()
546    ///     .register(|svc| svc.add_dbcontext_async(|options| { /* ... */ }))
547    ///     .try_build_async()
548    ///     .await?;
549    /// ```
550    pub async fn try_build_async(self) -> Result<Host> {
551        let prepared = self.prepare()?;
552        let PreparedHost {
553            builder,
554            svc,
555            provider_options,
556            options,
557            endpoints,
558            localizer,
559            handler_cache,
560        } = prepared;
561        let provider = svc
562            .build_async_with(provider_options)
563            .await
564            .map_err(build_provider_error)?;
565        builder.assemble(provider, handler_cache, options, endpoints, localizer)
566    }
567
568    fn prepare(mut self) -> Result<PreparedHost> {
569        // Initialize structured logging based on app mode.
570        // This is idempotent —subsequent calls are no-ops.
571        let env_filter = std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into());
572        if self.mode == AppMode::Development {
573            let _ = tracing_subscriber::fmt()
574                .with_env_filter(env_filter)
575                .without_time()
576                .with_target(false)
577                .try_init();
578        } else {
579            let _ = tracing_subscriber::fmt()
580                .json()
581                .with_env_filter(env_filter)
582                .try_init();
583        }
584
585        // Load configuration first: the localizer and endpoint catalog need the
586        // host-wide culture, and both are registered in DI.
587        let appsettings =
588            config::load_appsettings(self.mode).unwrap_or_else(|| serde_json::json!({}));
589        let mut options: AppOptions = config::bind_root(&appsettings);
590        for modifier in std::mem::take(&mut self.options_modifiers) {
591            modifier(&mut options);
592        }
593
594        let default_culture = if options.culture.default.trim().is_empty() {
595            "en".to_string()
596        } else {
597            options.culture.default.clone()
598        };
599        let localization = self
600            .localization
601            .take()
602            .unwrap_or_else(LocalizationOptions::none);
603        let localizer: Arc<dyn IStringLocalizer> = Arc::new(
604            StringLocalizer::new(default_culture, &localization)
605                .map_err(|error| Error::Di(format!("Invalid localization resources: {error}")))?,
606        );
607        let endpoints: Arc<dyn IEndpointCatalog> = Arc::new(EndpointCatalog::from_inventory());
608
609        let mut svc = ServiceCollection::from_injected();
610        for cfg in std::mem::take(&mut self.service_configs) {
611            svc = cfg(svc);
612        }
613
614        // The compiled handler registry is a host service: `add_mediator`
615        // resolves it from DI, and the HTTP path reads it from DispatchRuntime.
616        let handler_cache = Arc::new(webx_core::route::scan::HandlerCache::build());
617        svc = svc.instance(Arc::clone(&handler_cache));
618
619        // Host-provided defaults use `try_add*`: rust-dix 0.8+ resolves the *last*
620        // registration, so an earlier user registration must win over these.
621        let health_registry = Arc::clone(&self.health_registry);
622        svc = svc.try_add(move |_| Arc::clone(&health_registry));
623
624        // Endpoint metadata and localization are host services, not globals.
625        let endpoints_for_di = Arc::clone(&endpoints);
626        svc = svc.try_add_singleton::<dyn IEndpointCatalog>(move |_| Arc::clone(&endpoints_for_di));
627        let localizer_for_di = Arc::clone(&localizer);
628        svc = svc.try_add_singleton::<dyn IStringLocalizer>(move |_| Arc::clone(&localizer_for_di));
629
630        let provider_options = if self.lenient_scope_validation {
631            rust_dix::ServiceProviderOptions::lenient()
632        } else {
633            rust_dix::ServiceProviderOptions::default()
634        };
635        Ok(PreparedHost {
636            builder: self,
637            svc,
638            provider_options,
639            options,
640            endpoints,
641            localizer,
642            handler_cache,
643        })
644    }
645
646    #[allow(clippy::too_many_arguments)]
647    fn assemble(
648        self,
649        provider: Arc<ServiceProvider>,
650        handler_cache: Arc<webx_core::route::scan::HandlerCache>,
651        options: AppOptions,
652        endpoints: Arc<dyn IEndpointCatalog>,
653        localizer: Arc<dyn IStringLocalizer>,
654    ) -> Result<Host> {
655        // Per-host dispatch runtime (provider + handler registry).
656        let dispatch_runtime = Arc::new(DispatchRuntime::new(Arc::clone(&provider), handler_cache));
657
658        // Upload spooling and body limits come from the same merged config.
659        let body_limits = BodyLimits::from_options(&options);
660        body_limits.apply_spool_root();
661        tracing::info!(
662            "[Host] Uploads: max request {} bytes, max file {} bytes, spool >{} bytes to {}",
663            body_limits.multipart_body,
664            body_limits.file,
665            body_limits.memory_threshold,
666            body_limits.spool_dir.display()
667        );
668
669        let http_metrics = HttpMetrics::new();
670
671        let mut pipeline = MiddlewarePipeline::new();
672
673        // Pipeline order (see docs/rust-webx/07-middleware/ordering-strategy.md):
674        //
675        //   SecurityHeaders → RequestId → user(BeforeCors) → CORS → Metrics →
676        //   RateLimit → auth → user(AfterAuth, default) → SPA → user(AfterSpa) → Router
677        //
678        // CORS runs before the short-circuiting middlewares so a 429/413/401
679        // still carries Access-Control-Allow-Origin; Metrics runs before
680        // RateLimit so rate-limited requests are counted; user middleware
681        // defaults to AfterAuth so it can read the authenticated principal.
682        pipeline.add_middleware(Arc::new(
683            crate::security_headers::SecurityHeadersMiddleware::new(),
684        ));
685        pipeline.add_middleware(Arc::new(crate::request_id::RequestIdMiddleware::new()));
686
687        for spec in self
688            .middleware_specs
689            .iter()
690            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::BeforeCors)
691        {
692            pipeline.add_middleware((spec.factory)());
693        }
694
695        let cors = self.cors_config.unwrap_or_else(|| {
696            let cs = &options.cors;
697            CorsConfig {
698                origins: cs.origins.clone(),
699                methods: cs.methods.clone(),
700                headers: cs.headers.clone(),
701                expose_headers: cs.expose_headers.clone(),
702                allow_credentials: cs.allow_credentials,
703                max_age: cs.max_age,
704            }
705        });
706        pipeline.add_middleware(Arc::new(CorsMiddleware::new(cors)));
707
708        if options.metrics.enabled {
709            tracing::info!("[Host] Metrics enabled at GET /metrics");
710            pipeline.add_middleware(Arc::new(MetricsMiddleware::new(Arc::clone(&http_metrics))));
711        }
712
713        if options.rate_limit.enabled {
714            tracing::info!(
715                "[Host] RateLimit enabled: {} req/s, burst {}, max_ips {}",
716                options.rate_limit.requests_per_second,
717                options.rate_limit.burst_size,
718                options.rate_limit.max_tracked_ips
719            );
720            pipeline.add_middleware(Arc::new(RateLimitMiddleware::from_config(
721                &options.rate_limit,
722            )));
723        }
724
725        if self.auth_enabled {
726            let secret = options.jwt.secret.clone();
727            if !secret.is_empty() {
728                let jwt_auth = Arc::new(JwtAuth::new(
729                    DecodingKey::from_secret(secret.as_bytes()),
730                    Validation::default(),
731                ));
732                pipeline.add_middleware(Arc::new(jwt_middleware(jwt_auth)));
733                init_jwt_secret(&secret);
734            } else {
735                tracing::warn!(
736                    "add_authentication() enabled but no JWT secret configured. Set Jwt.Secret in appsettings.json, JWT_SECRET, or APP__Jwt__Secret env var."
737                );
738            }
739        }
740
741        // User middlewares in the default slot: after authentication, so
742        // `ctx.claims()` is populated. Middlewares registered directly on the
743        // DI container join the same slot, after the builder-registered ones.
744        for spec in self
745            .middleware_specs
746            .iter()
747            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterAuth)
748        {
749            pipeline.add_middleware((spec.factory)());
750        }
751        for mw in provider.get_all::<dyn IMiddleware>() {
752            pipeline.add_middleware(mw);
753        }
754
755        // SPA 自动启用:若应用显式调用 `use_spa`,使用指定的文件源;
756        // 否则框架自动检测应用基准目录下的 `wwwroot/`,存在即启用 SPA。
757        // 这样新应用无需在 main.rs 手写 `use_spa("wwwroot")` 样板。
758        // `no_spa()` 可显式禁用此行为(含自动检测),用于纯 API 主机或测试隔离。
759        // A registered `embed_assets!()` table is layered underneath automatically.
760        // SPA runs after JWT so auth middleware sees API requests first; SpaMiddleware
761        // skips /api/* paths so unmatched API routes return 404 from the router.
762        let spa_source = if self.spa_disabled {
763            None
764        } else {
765            let base = self.spa_source.clone().or_else(|| {
766                let candidate = webx_core::paths::app_base().join("wwwroot");
767                if candidate.is_dir() {
768                    tracing::info!("[Host] Auto-detected SPA root: {}", candidate.display());
769                    Some(SpaSource::Disk(candidate.to_string_lossy().into_owned()))
770                } else {
771                    None
772                }
773            });
774            with_embedded_assets(base, EmbeddedAssets::registered().copied())
775        };
776        let mut spa_fallback: Option<Arc<SpaMiddleware>> = None;
777        if let Some(ref source) = spa_source {
778            let spa = Arc::new(SpaMiddleware::from_source(source.clone()));
779            log_spa_source(&spa);
780            pipeline.add_middleware(Arc::clone(&spa) as Arc<dyn IMiddleware>);
781            // Kept for the post-routing index fallback: static files are served
782            // before routing, but the SPA shell is only served when the router
783            // has no match.
784            spa_fallback = Some(spa);
785        }
786
787        // Last-chance hooks: after static files/SPA, immediately before routing.
788        for spec in self
789            .middleware_specs
790            .iter()
791            .filter(|spec| spec.slot == webx_core::middleware::MiddlewareSlot::AfterSpa)
792        {
793            pipeline.add_middleware((spec.factory)());
794        }
795
796        let pipeline = Arc::new(pipeline);
797
798        // Security: fail fast in production when auth is enabled but JWT secret is missing or weak
799        if self.mode == AppMode::Production
800            && self.auth_enabled
801            && is_weak_jwt_secret(&options.jwt.secret)
802        {
803            return Err(Error::Di(
804                "Production requires a strong JWT secret. Set APP__Jwt__Secret or JWT_SECRET env var (min 32 chars, not a placeholder)."
805                    .to_string(),
806            ));
807        }
808
809        // Security: fail fast in production when CORS allows all origins
810        if self.mode == AppMode::Production && options.cors.origins.iter().any(|o| o == "*") {
811            return Err(Error::Di(
812                "Production forbids CORS origin '*'. Set explicit origins via Cors.Origins or APP__Cors__Origins."
813                    .to_string(),
814            ));
815        }
816
817        let mut router = Router::new();
818        let mut route_count = 0usize;
819
820        crate::diagnostics::validate_route_configuration()?;
821
822        // Build dispatch map: handler_type → dispatch function
823        let mut dispatch_map: std::collections::HashMap<
824            &'static str,
825            webx_core::route::scan::RouteDispatchFn,
826        > = std::collections::HashMap::new();
827
828        for dispatch in inventory::iter::<webx_core::route::scan::RouteDispatch> {
829            dispatch_map.insert(dispatch.handler_type, dispatch.dispatch);
830        }
831
832        for entry in inventory::iter::<RouteEntry> {
833            route_count += 1;
834            let stub = Arc::new(StubEndpoint {
835                method: entry.method.as_str(),
836                path: entry.path,
837                handler_type: entry.handler_type,
838                dispatch_fn: dispatch_map.get(entry.handler_type).copied(),
839                authorization: entry.authorization,
840                authorizers: collect_authorizers(provider.as_ref()),
841            });
842            router.register(entry.method, entry.path, stub);
843        }
844
845        let openapi_spec = generate_openapi_spec(
846            "Rust WebX API",
847            env!("CARGO_PKG_VERSION"),
848            localizer.as_ref(),
849        );
850        if self.mode == AppMode::Development {
851            let openapi_bytes = serde_json::to_vec(&openapi_spec).unwrap_or_default();
852            router.register(
853                HttpMethod::Get,
854                "/api/openapi.json",
855                Arc::new(StaticJsonEndpoint::new(openapi_bytes)),
856            );
857            router.register(
858                HttpMethod::Get,
859                "/api/openapi.html",
860                Arc::new(StaticHtmlEndpoint { body: APIUI_HTML }),
861            );
862        }
863
864        // Health check endpoints (RFC 8407 application/health+json)
865        // Probes are evaluated on each request (not baked at build time).
866        let health_registry = Arc::clone(&self.health_registry);
867        let health_endpoint: Arc<dyn IEndpoint> =
868            Arc::new(HealthCheckEndpoint::new(Arc::clone(&health_registry)));
869        router.register(HttpMethod::Get, "/health", Arc::clone(&health_endpoint));
870        router.register(HttpMethod::Get, "/healthz", health_endpoint);
871
872        // /health/live: liveness (process alive → pass, no probe queries)
873        let live_body =
874            serde_json::to_vec(&serde_json::json!({"status":"pass"})).unwrap_or_default();
875        router.register(
876            HttpMethod::Get,
877            "/health/live",
878            Arc::new(StaticJsonEndpoint {
879                body: live_body,
880                content_type: "application/health+json",
881            }),
882        );
883
884        // /health/ready: readiness (queries registry, same as /health)
885        router.register(
886            HttpMethod::Get,
887            "/health/ready",
888            Arc::new(HealthCheckEndpoint::new(health_registry)),
889        );
890
891        if options.metrics.enabled {
892            router.register(
893                HttpMethod::Get,
894                "/metrics",
895                Arc::new(MetricsEndpoint::new(http_metrics)),
896            );
897        }
898
899        if self.mode == AppMode::Development {
900            let version = env!("CARGO_PKG_VERSION");
901            tracing::info!("");
902            tracing::info!("  ----------------------------------------------------------------");
903            tracing::info!("    Rust WebX Framework v{}", version);
904            tracing::info!("  ----------------------------------------------------------------");
905            tracing::info!("    App:      {}", options.app.name);
906            tracing::info!("    CORS:     enabled");
907            if let Some(source) = spa_source.as_ref() {
908                tracing::info!("    SPA Root: {}", source.disk_root());
909                if let Some(assets) = source.embedded() {
910                    tracing::info!(
911                        "    Embedded: {} files, {:.1} MiB from {:.1} MiB (built from {})",
912                        assets.len(),
913                        assets.total_bytes() as f64 / (1024.0 * 1024.0),
914                        assets.total_raw_bytes() as f64 / (1024.0 * 1024.0),
915                        assets.root(),
916                    );
917                }
918            }
919            if route_count > 0 {
920                tracing::info!("    Routes:   {} registered", route_count);
921            }
922            crate::diagnostics::log_startup_diagnostics();
923            let banner_urls = if options.app.urls.is_empty() {
924                vec!["http://localhost:5000".to_string()]
925            } else {
926                options
927                    .app
928                    .urls
929                    .iter()
930                    .map(|u| u.replace("0.0.0.0", "localhost"))
931                    .collect::<Vec<_>>()
932            };
933            for url in &banner_urls {
934                tracing::info!("    OpenAPI:  {}/api/openapi.html", url);
935                tracing::info!("    OpenAPI:  {}/api/openapi.json", url);
936            }
937            tracing::info!("  ----------------------------------------------------------------");
938            tracing::info!("");
939        } else if route_count > 0 {
940            tracing::info!("{} route(s) registered", route_count);
941        }
942
943        let router = Arc::new(router);
944        let router_handler = make_router_handler(Arc::clone(&router), spa_fallback);
945
946        // Resolve all registered hosted services from the DI container.
947        // These will be started when `run()` is called and stopped on shutdown.
948        let hosted_services: Vec<Arc<dyn IHostedService>> =
949            provider.get_all::<dyn IHostedService>();
950
951        Ok(Host {
952            provider,
953            dispatch_runtime,
954            options,
955            endpoints,
956            localizer,
957            body_limits,
958            pipeline,
959            router,
960            router_handler,
961            mode: self.mode,
962            spa_source,
963            shutdown: Arc::new(tokio::sync::watch::channel(false).0),
964            hosted_services,
965        })
966    }
967
968    /// Build the host, panicking on configuration errors (fail fast at startup).
969    ///
970    /// Equivalent to [`HostBuilder::try_build`] followed by a panic that keeps
971    /// the historical message, so a misconfigured deployment stops the process
972    /// instead of serving with weakened guards. Call `try_build` directly when
973    /// the caller wants to handle the error itself.
974    pub fn build(self) -> Host {
975        match self.try_build() {
976            Ok(host) => host,
977            // `Error::Di` carries the operator-facing text without a variant
978            // prefix, keeping the panic message identical to pre-0.10 builds.
979            Err(error) => panic!("{}", build_failure_message(&error)),
980        }
981    }
982}
983
984/// Map a rust-dix provider build failure to the host `Error::Di` form.
985fn build_provider_error(error: rust_dix::RdiError) -> Error {
986    Error::Di(format!(
987        "Failed to build ServiceProvider: {}. Check your DI registrations.",
988        error
989    ))
990}
991
992/// Operator-facing text of a `try_build` failure, without the `Error` variant
993/// prefix (the `build()` panic message must not change in 0.10).
994fn build_failure_message(error: &Error) -> String {
995    match error {
996        Error::Di(message) => message.clone(),
997        other => other.to_string(),
998    }
999}
1000
1001/// Handle for a running server, allowing programmatic graceful shutdown.
1002pub struct ServerHandle {
1003    shutdown: ShutdownSignal,
1004}
1005
1006impl ServerHandle {
1007    /// Signal the server to stop accepting new connections and begin
1008    /// draining existing ones.
1009    ///
1010    /// The signal is stored in a `watch` channel, so calling this before the
1011    /// server's accept loops start is not lost.
1012    pub fn shutdown(self) {
1013        trigger_shutdown(&self.shutdown);
1014    }
1015}
1016
1017/// A fully built server that owns its runtime lifecycle.
1018///
1019/// Created via `Host::into_server()`. Provides a graceful shutdown
1020/// handle alongside the `run()` method.
1021///
1022/// ```ignore
1023/// let server = Host::builder().build().into_server();
1024/// let handle = server.handle();
1025/// tokio::spawn(async move { server.run().await });
1026/// handle.shutdown();
1027/// ```
1028pub struct Server {
1029    host: Host,
1030}
1031
1032impl Server {
1033    /// Start listening on all configured URLs.
1034    pub async fn run(self) -> Result<()> {
1035        self.host.run().await
1036    }
1037
1038    /// Start listening on a single address (convenience).
1039    pub async fn run_at(self, addr: &str) -> Result<()> {
1040        self.host.run_at(addr).await
1041    }
1042
1043    /// Obtain a shutdown handle before starting.
1044    pub fn handle(&self) -> ServerHandle {
1045        self.host.server_handle()
1046    }
1047}
1048
1049impl Default for HostBuilder {
1050    fn default() -> Self {
1051        Self::new()
1052    }
1053}
1054
1055impl Host {
1056    pub fn builder() -> HostBuilder {
1057        HostBuilder::new()
1058    }
1059
1060    pub fn options(&self) -> &AppOptions {
1061        &self.options
1062    }
1063
1064    /// Root DI container for this host instance.
1065    pub fn provider(&self) -> &Arc<ServiceProvider> {
1066        &self.provider
1067    }
1068
1069    /// Dispose the DI provider after the server and hosted services stopped.
1070    ///
1071    /// rust-dix releases singletons/root-scoped instances in reverse creation
1072    /// order, runs cleanup closures in reverse registration order and marks the
1073    /// provider disposed; later resolution returns `ProviderDisposed`. The call
1074    /// is idempotent, so a second shutdown is harmless.
1075    fn dispose_di(&self) {
1076        self.provider.dispose();
1077        tracing::debug!("[Host] DI provider disposed");
1078    }
1079
1080    /// Instance-scoped dispatch runtime (provider + handler cache).
1081    pub fn dispatch_runtime(&self) -> &Arc<DispatchRuntime> {
1082        &self.dispatch_runtime
1083    }
1084
1085    /// Endpoint metadata for this host.
1086    ///
1087    /// Built once from the compile-time route table; use it instead of reading
1088    /// `inventory` directly. Resolve names through [`Self::localizer`] or the
1089    /// DI-registered `IStringLocalizer`.
1090    pub fn endpoints(&self) -> &dyn IEndpointCatalog {
1091        &*self.endpoints
1092    }
1093
1094    /// Localizer for this host's configured `Culture.Default`.
1095    pub fn localizer(&self) -> &dyn IStringLocalizer {
1096        &*self.localizer
1097    }
1098
1099    /// Start the server on all URLs configured in AppOptions.app.Urls.
1100    ///
1101    /// Automatically detects http:// and https:// URLs from the array.
1102    /// Starts a plain TCP listener for each http:// URL and a TLS listener
1103    /// for each https:// URL. All listeners run concurrently.
1104    ///
1105    /// Supports graceful shutdown on Ctrl+C/SIGTERM with 30s connection drain.
1106    ///
1107    /// # Example
1108    ///
1109    /// ```json
1110    /// { "App": { "Urls": ["http://localhost:5000", "https://localhost:5030"] } }
1111    /// ```
1112    pub async fn run(&self) -> Result<()> {
1113        let urls = if self.options.app.urls.is_empty() {
1114            vec!["http://0.0.0.0:5000".to_string()]
1115        } else {
1116            self.options.app.urls.clone()
1117        };
1118
1119        let mut http_addrs: Vec<String> = Vec::new();
1120        let mut https_addrs: Vec<String> = Vec::new();
1121
1122        for url in &urls {
1123            let (scheme, addr) = parse_url(url)?;
1124            match scheme {
1125                "http" => http_addrs.push(addr),
1126                "https" => https_addrs.push(addr),
1127                other => {
1128                    return Err(webx_core::error::Error::Http(format!(
1129                        "Unsupported URL scheme '{}' in '{}'",
1130                        other, url
1131                    )))
1132                }
1133            }
1134        }
1135
1136        let acceptor = if !https_addrs.is_empty() {
1137            let tls = &self.options.tls;
1138            if tls.cert_path.is_empty() || tls.key_path.is_empty() {
1139                return Err(webx_core::error::Error::Http(
1140                    "HTTPS URLs require Tls.CertPath and Tls.KeyPath".into(),
1141                ));
1142            }
1143            Some(build_tls_acceptor(&tls.cert_path, &tls.key_path)?)
1144        } else {
1145            None
1146        };
1147
1148        // Bind every listener *before* starting hosted services or spawning
1149        // accept loops. A port conflict must fail `run()`, not be logged and
1150        // forgotten (which used to leave the process exiting with code 0).
1151        let mut http_listeners = Vec::with_capacity(http_addrs.len());
1152        for addr in &http_addrs {
1153            http_listeners.push(bind_listener(addr, "HTTP").await?);
1154        }
1155        let mut https_listeners = Vec::with_capacity(https_addrs.len());
1156        for addr in &https_addrs {
1157            https_listeners.push(bind_listener(addr, "HTTPS").await?);
1158        }
1159
1160        if let Err(err) = start_hosted_services(&self.hosted_services).await {
1161            self.dispose_di();
1162            return Err(err);
1163        }
1164
1165        // Banner
1166        if self.mode == AppMode::Development {
1167            tracing::info!("");
1168            for url in &urls {
1169                let display_url = url.replace("0.0.0.0", "localhost");
1170                tracing::info!("  Listening on {}", display_url);
1171            }
1172        } else {
1173            tracing::info!("Listening on {} url(s)", urls.len());
1174        }
1175
1176        install_shutdown_handler(Arc::clone(&self.shutdown));
1177        spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1178
1179        let mut handles = Vec::new();
1180        let pipeline = Arc::clone(&self.pipeline);
1181        let router_handler = self.router_handler.clone();
1182        let dispatch_runtime = Arc::clone(&self.dispatch_runtime);
1183        let mode = self.mode;
1184        let body_limits = self.body_limits.clone();
1185        let max_connections = self.options.app.max_connections;
1186
1187        for (addr, listener) in http_addrs.into_iter().zip(http_listeners) {
1188            handles.push(tokio::spawn(serve_http(
1189                addr,
1190                listener,
1191                self.shutdown.subscribe(),
1192                Arc::clone(&pipeline),
1193                router_handler.clone(),
1194                Arc::clone(&dispatch_runtime),
1195                mode,
1196                body_limits.clone(),
1197                max_connections,
1198            )));
1199        }
1200
1201        if let Some(ref tls_acceptor) = acceptor {
1202            for (addr, listener) in https_addrs.into_iter().zip(https_listeners) {
1203                handles.push(tokio::spawn(serve_https(
1204                    addr,
1205                    tls_acceptor.clone(),
1206                    listener,
1207                    self.shutdown.subscribe(),
1208                    Arc::clone(&pipeline),
1209                    router_handler.clone(),
1210                    Arc::clone(&dispatch_runtime),
1211                    mode,
1212                    body_limits.clone(),
1213                    max_connections,
1214                )));
1215            }
1216        }
1217
1218        // Wait for all listeners to finish (they exit after the shutdown
1219        // signal); a panicked listener is a host failure, not a silent exit.
1220        let mut run_error: Option<webx_core::error::Error> = None;
1221        for h in handles {
1222            if let Err(join_err) = h.await {
1223                if run_error.is_none() {
1224                    run_error = Some(webx_core::error::Error::Internal(format!(
1225                        "a listener task failed: {join_err}"
1226                    )));
1227                }
1228            }
1229        }
1230
1231        stop_hosted_services(&self.hosted_services).await;
1232        self.dispose_di();
1233
1234        match run_error {
1235            Some(err) => Err(err),
1236            None => Ok(()),
1237        }
1238    }
1239
1240    /// Start the server at a single explicit address (convenience wrapper).
1241    pub async fn run_at(&self, addr: &str) -> Result<()> {
1242        let listener = bind_listener(addr, "HTTP").await?;
1243        if let Err(err) = start_hosted_services(&self.hosted_services).await {
1244            self.dispose_di();
1245            return Err(err);
1246        }
1247
1248        install_shutdown_handler(Arc::clone(&self.shutdown));
1249        spawn_async_shutdown_listener(Arc::clone(&self.shutdown));
1250        serve_http(
1251            addr.to_string(),
1252            listener,
1253            self.shutdown.subscribe(),
1254            Arc::clone(&self.pipeline),
1255            self.router_handler.clone(),
1256            Arc::clone(&self.dispatch_runtime),
1257            self.mode,
1258            self.body_limits.clone(),
1259            self.options.app.max_connections,
1260        )
1261        .await;
1262
1263        stop_hosted_services(&self.hosted_services).await;
1264        self.dispose_di();
1265        Ok(())
1266    }
1267
1268    /// Return a `ServerHandle` that can be used to signal graceful shutdown
1269    /// from application code (e.g., integration tests, health checks).
1270    ///
1271    /// ```ignore
1272    /// let host = Host::builder().build();
1273    /// let handle = host.server_handle();
1274    /// tokio::spawn(async move { host.run().await });
1275    /// // ... later:
1276    /// handle.shutdown();
1277    /// ```
1278    pub fn server_handle(&self) -> ServerHandle {
1279        ServerHandle {
1280            shutdown: Arc::clone(&self.shutdown),
1281        }
1282    }
1283
1284    /// Consume the host and return a `Server` that owns the runtime lifecycle.
1285    ///
1286    /// The returned `Server` can be `.run().await`-ed and provides a
1287    /// handle for graceful shutdown.
1288    pub fn into_server(self) -> Server {
1289        Server { host: self }
1290    }
1291}
1292
1293#[async_trait::async_trait]
1294impl IHost for Host {
1295    async fn run(&self, addr: &str) -> Result<()> {
1296        self.run_at(addr).await
1297    }
1298    async fn stop(&self) -> Result<()> {
1299        tracing::info!("Stop requested.");
1300        trigger_shutdown(&self.shutdown);
1301        Ok(())
1302    }
1303}
1304
1305fn make_router_handler(router: Arc<Router>, spa_fallback: Option<Arc<SpaMiddleware>>) -> HandlerFn {
1306    Arc::new(move |ctx: &mut dyn IHttpContext| {
1307        let router = Arc::clone(&router);
1308        let spa_fallback = spa_fallback.clone();
1309        Box::pin(async move {
1310            // The context may carry a rejection detected from the request line
1311            // (oversized declared Content-Length). Failing here — inside the
1312            // pipeline — keeps CORS/security headers/logging/metrics in play.
1313            if let Some(err) = ctx.take_early_error() {
1314                return Err(err);
1315            }
1316            match router.match_route(ctx).await? {
1317                Some((endpoint, params, pattern)) => {
1318                    drop(router);
1319                    for (key, value) in params {
1320                        ctx.request_mut().route_params_mut().insert(key, value);
1321                    }
1322                    *ctx.request_mut().route_pattern_mut() = Some(pattern);
1323                    endpoint.handle(ctx).await
1324                }
1325                None => {
1326                    let path_exists = router.path_exists(ctx.request().path());
1327                    let allowed = if path_exists {
1328                        router.allowed_methods(ctx.request().path())
1329                    } else {
1330                        Vec::new()
1331                    };
1332                    drop(router);
1333                    // Don't overwrite if a middleware (e.g. a static file
1334                    // response) already wrote a body.
1335                    if !ctx.response().has_body() {
1336                        // SPA routing: only a genuine router miss gets the app
1337                        // shell, and only when the client asked for HTML.
1338                        if let Some(spa) = spa_fallback {
1339                            if spa.try_index_fallback(ctx).await? {
1340                                return Ok(());
1341                            }
1342                        }
1343                        if path_exists {
1344                            if ctx.request().method().eq_ignore_ascii_case("OPTIONS") {
1345                                // Method discovery (RFC 9110 §9.3.7): CORS
1346                                // preflights are answered earlier, so this is a
1347                                // plain OPTIONS and must report what the path
1348                                // actually allows.
1349                                ctx.response_mut().set_status(204);
1350                                ctx.response_mut().set_header("allow", &allowed.join(", "));
1351                            } else {
1352                                // RFC 9110 §15.5.6: a 405 must carry `Allow`.
1353                                write_method_not_allowed(ctx, &allowed).await;
1354                            }
1355                        } else {
1356                            write_plain_error(ctx, 404, "Not Found").await;
1357                        }
1358                    }
1359                    Ok(())
1360                }
1361            }
1362        })
1363    })
1364}
1365
1366async fn handle_request(
1367    req: Request<Incoming>,
1368    pipeline: Arc<MiddlewarePipeline>,
1369    router_handler: HandlerFn,
1370    dispatch_runtime: Arc<DispatchRuntime>,
1371    body_limits: BodyLimits,
1372    remote_addr: Option<std::net::SocketAddr>,
1373) -> std::result::Result<hyper::Response<RespBody>, std::convert::Infallible> {
1374    let mut ctx = HttpContext::new(req, body_limits, remote_addr, Arc::clone(&dispatch_runtime));
1375
1376    let result = pipeline.execute(&mut ctx, router_handler).await;
1377    if let Err(e) = result {
1378        match e {
1379            // A binding/validation failure already carries its full
1380            // ProblemDetails (including the `errors` extension); write it
1381            // verbatim instead of flattening it into a detail string.
1382            webx_core::error::Error::Problem(problem) => {
1383                write_problem_response(&mut ctx, *problem).await
1384            }
1385            other => {
1386                write_framework_error(&mut ctx, &other).await;
1387            }
1388        }
1389    }
1390
1391    Ok(ctx.into_response().await)
1392}
1393
1394/// Write an error the framework itself produced (not an `Error::Problem`).
1395///
1396/// * 401 goes through the single unauthorized writer so every challenge is
1397///   identical and carries `WWW-Authenticate`; the variant's own message
1398///   ("authentication required" vs "invalid or expired token") is kept so a
1399///   client can tell missing credentials from a rejected token.
1400/// * 5xx never echoes the internal message: it is logged and replaced with a
1401///   stable detail, because database errors and file paths must not reach
1402///   clients.
1403/// * 4xx keep their (client-facing) detail, taken from the variant without the
1404///   operator-facing `Display` prefix.
1405async fn write_framework_error(ctx: &mut dyn IHttpContext, error: &webx_core::error::Error) {
1406    let status = error.status_code();
1407    if status == 401 {
1408        write_unauthorized(
1409            ctx,
1410            error.public_detail().unwrap_or("authentication required"),
1411        )
1412        .await;
1413    } else if status >= 500 {
1414        tracing::error!(status, error = %error, "unhandled server error");
1415        write_problem_response(ctx, build_problem(status, "an unexpected error occurred")).await;
1416    } else {
1417        write_problem_response(
1418            ctx,
1419            build_problem(status, error.public_detail().unwrap_or("request failed")),
1420        )
1421        .await;
1422    }
1423}
1424
1425/// Write a framework-generated 4xx that has no [`Error`] value behind it
1426/// (404 from an unmatched route).
1427async fn write_plain_error(ctx: &mut dyn IHttpContext, status: u16, message: &str) {
1428    write_problem_response(ctx, build_problem(status, message)).await;
1429}
1430
1431/// Write a 405 with the `Allow` header RFC 9110 §15.5.6 requires.
1432async fn write_method_not_allowed(ctx: &mut dyn IHttpContext, allowed: &[&str]) {
1433    let allow = allowed.join(", ");
1434    ctx.response_mut().set_header("allow", &allow);
1435    write_plain_error(ctx, 405, "Method Not Allowed").await;
1436}
1437
1438// ---------------------------------------------------------------------------
1439// Shutdown helpers
1440// ---------------------------------------------------------------------------
1441
1442/// Raise the shutdown flag.
1443///
1444/// `watch::Sender::send` stores the value, so a listener that subscribes later
1445/// (or a listener between two `select!` iterations) still observes it — unlike
1446/// `Notify::notify_waiters`, which drops the wakeup when nobody is parked.
1447fn trigger_shutdown(shutdown: &ShutdownSignal) {
1448    // `send_replace` stores the value unconditionally. `send` would fail while
1449    // no receiver exists (e.g. `handle.shutdown()` called before `run()`
1450    // subscribes) and silently drop the signal — the exact bug this type
1451    // replaces `Notify` for.
1452    shutdown.send_replace(true);
1453}
1454
1455/// Resolve once the host has been asked to shut down.
1456///
1457/// Returns immediately when the flag is already set; also returns when the
1458/// sender disappears (host dropped) so a connection task never hangs forever.
1459async fn wait_for_shutdown(shutdown: &mut tokio::sync::watch::Receiver<bool>) {
1460    if *shutdown.borrow() {
1461        return;
1462    }
1463    loop {
1464        if shutdown.changed().await.is_err() {
1465            return;
1466        }
1467        if *shutdown.borrow() {
1468            return;
1469        }
1470    }
1471}
1472
1473/// Register a process-wide Ctrl+C / SIGINT handler.
1474///
1475/// On Windows, `cargo run` may exit the parent without stopping the child;
1476/// registering here ensures the server process itself receives the signal.
1477/// A second Ctrl+C forces immediate exit.
1478///
1479/// `ctrlc::set_handler` can only be installed once per process, so every host
1480/// registers its signal (weakly) here and the single handler raises all of
1481/// them. That keeps two hosts in one process — integration tests, embedded
1482/// scenarios — individually stoppable by Ctrl+C.
1483fn install_shutdown_handler(shutdown: ShutdownSignal) {
1484    use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1485    use std::sync::{Mutex, OnceLock, Weak};
1486
1487    static HANDLERS: OnceLock<Mutex<Vec<Weak<tokio::sync::watch::Sender<bool>>>>> = OnceLock::new();
1488    static CTRL_COUNT: AtomicUsize = AtomicUsize::new(0);
1489    static INSTALLED: AtomicBool = AtomicBool::new(false);
1490
1491    let handlers = HANDLERS.get_or_init(|| Mutex::new(Vec::new()));
1492    {
1493        let mut registered = handlers
1494            .lock()
1495            .unwrap_or_else(|poisoned| poisoned.into_inner());
1496        // Drop hosts that have already exited before adding this one.
1497        registered.retain(|weak| weak.strong_count() > 0);
1498        registered.push(Arc::downgrade(&shutdown));
1499    }
1500
1501    // `ctrlc::set_handler` is process-wide and may only be called once. The
1502    // flag (not the registry length) decides: hosts may come and go, but the
1503    // handler stays installed and raises whichever hosts are alive.
1504    if INSTALLED.swap(true, Ordering::SeqCst) {
1505        return;
1506    }
1507
1508    if let Err(e) = ctrlc::set_handler(move || {
1509        let n = CTRL_COUNT.fetch_add(1, Ordering::SeqCst);
1510        if n == 0 {
1511            tracing::info!("Shutdown signal received, draining connections...");
1512            let registered = handlers
1513                .lock()
1514                .unwrap_or_else(|poisoned| poisoned.into_inner());
1515            for weak in registered.iter() {
1516                if let Some(shutdown) = weak.upgrade() {
1517                    trigger_shutdown(&shutdown);
1518                }
1519            }
1520        } else {
1521            tracing::warn!("Second interrupt — force exiting.");
1522            std::process::exit(130);
1523        }
1524    }) {
1525        tracing::warn!("Could not install Ctrl+C handler: {}", e);
1526    }
1527}
1528
1529/// Tokio-based listener for Ctrl+C (all platforms) and SIGTERM (Unix).
1530fn spawn_async_shutdown_listener(shutdown: ShutdownSignal) {
1531    tokio::spawn(async move {
1532        wait_for_shutdown_signal().await;
1533        tracing::info!("Async shutdown signal received, draining connections...");
1534        trigger_shutdown(&shutdown);
1535    });
1536}
1537
1538async fn wait_for_shutdown_signal() {
1539    #[cfg(unix)]
1540    {
1541        use tokio::signal::unix::{signal, SignalKind};
1542        let mut sigterm = signal(SignalKind::terminate()).expect("install SIGTERM handler");
1543        tokio::select! {
1544            res = tokio::signal::ctrl_c() => {
1545                if let Err(e) = res {
1546                    tracing::warn!("Ctrl+C listener error: {}", e);
1547                }
1548            }
1549            _ = sigterm.recv() => {}
1550        }
1551    }
1552    #[cfg(not(unix))]
1553    {
1554        if let Err(e) = tokio::signal::ctrl_c().await {
1555            tracing::warn!("Ctrl+C listener error: {}", e);
1556        }
1557    }
1558}
1559
1560/// How long a hosted service may take to start before the host gives up.
1561const HOSTED_START_TIMEOUT_SECS: u64 = 30;
1562/// How long a hosted service may take to stop before it is skipped.
1563const HOSTED_STOP_TIMEOUT_SECS: u64 = 30;
1564
1565async fn start_hosted_services(services: &[Arc<dyn IHostedService>]) -> Result<()> {
1566    if services.is_empty() {
1567        return Ok(());
1568    }
1569    tracing::info!("Starting {} hosted service(s)...", services.len());
1570    let mut started = 0usize;
1571    let mut failure: Option<webx_core::error::Error> = None;
1572
1573    for svc in services {
1574        // No ambient DI scope is created here: hosted services own their scope
1575        // via an injected `Arc<dyn ScopeFactory>` (rust-dix built-in) and dispose
1576        // it themselves, so scoped dependencies never outlive the service run.
1577        let attempt = tokio::time::timeout(
1578            std::time::Duration::from_secs(HOSTED_START_TIMEOUT_SECS),
1579            svc.start(),
1580        )
1581        .await;
1582        match attempt {
1583            Ok(Ok(())) => started += 1,
1584            Ok(Err(err)) => {
1585                failure = Some(err);
1586                break;
1587            }
1588            Err(_) => {
1589                failure = Some(webx_core::error::Error::Internal(format!(
1590                    "hosted service start timed out after {HOSTED_START_TIMEOUT_SECS}s"
1591                )));
1592                break;
1593            }
1594        }
1595    }
1596
1597    match failure {
1598        None => {
1599            tracing::info!("All {started} hosted service(s) started.");
1600            Ok(())
1601        }
1602        Some(err) => {
1603            // Roll back the services that did start, newest first, so a failed
1604            // startup does not leave pools/loops running.
1605            for (index, svc) in services[..started].iter().enumerate().rev() {
1606                stop_hosted_service(index, svc).await;
1607            }
1608            Err(err)
1609        }
1610    }
1611}
1612
1613async fn stop_hosted_services(services: &[Arc<dyn IHostedService>]) {
1614    if services.is_empty() {
1615        return;
1616    }
1617    tracing::info!("Stopping {} hosted service(s)...", services.len());
1618    for (index, svc) in services.iter().enumerate().rev() {
1619        stop_hosted_service(index, svc).await;
1620    }
1621    tracing::info!("All hosted services stopped.");
1622}
1623
1624/// Stop one service with a timeout (and a fresh DI scope), logging failures.
1625async fn stop_hosted_service(index: usize, svc: &Arc<dyn IHostedService>) {
1626    match tokio::time::timeout(
1627        std::time::Duration::from_secs(HOSTED_STOP_TIMEOUT_SECS),
1628        svc.stop(),
1629    )
1630    .await
1631    {
1632        Ok(Ok(())) => {}
1633        Ok(Err(err)) => {
1634            tracing::warn!(service_index = index, error = %err, "hosted service stop error")
1635        }
1636        Err(_) => tracing::error!(
1637            service_index = index,
1638            timeout_secs = HOSTED_STOP_TIMEOUT_SECS,
1639            "hosted service stop timed out"
1640        ),
1641    }
1642}
1643
1644async fn drain_connections(join_set: &mut JoinSet<()>, label: &str, mode: AppMode) {
1645    let timeout_secs = if mode == AppMode::Development { 5 } else { 30 };
1646    let drain_future = async {
1647        while let Some(result) = join_set.join_next().await {
1648            if let Err(e) = result {
1649                tracing::error!("Connection task panicked: {:?}", e);
1650            }
1651        }
1652    };
1653
1654    match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), drain_future).await {
1655        Ok(_) => tracing::info!("{}: drained.", label),
1656        Err(_) => {
1657            tracing::warn!("{}: drain timeout, aborting connections.", label);
1658            join_set.abort_all();
1659            while join_set.join_next().await.is_some() {}
1660        }
1661    }
1662}
1663
1664// ---------------------------------------------------------------------------
1665// URL parsing & binding helpers
1666// ---------------------------------------------------------------------------
1667
1668/// Bind a TCP listener, turning an OS-level failure into a host error.
1669///
1670/// Binding happens before any accept loop starts, so a port conflict fails
1671/// `run()`/`run_at()` with a non-zero exit instead of a log line and a silent
1672/// `Ok(())`.
1673async fn bind_listener(addr: &str, label: &str) -> Result<tokio::net::TcpListener> {
1674    tokio::net::TcpListener::bind(addr).await.map_err(|err| {
1675        webx_core::error::Error::Http(format!("failed to bind {label} on {addr}: {err}"))
1676    })
1677}
1678
1679/// Parse a URL string into (scheme, addr) pair.
1680/// e.g., "https://0.0.0.0:5030" →("https", "0.0.0.0:5030")
1681fn parse_url(url: &str) -> Result<(&str, String)> {
1682    if let Some(rest) = url.strip_prefix("https://") {
1683        Ok(("https", rest.to_string()))
1684    } else if let Some(rest) = url.strip_prefix("http://") {
1685        Ok(("http", rest.to_string()))
1686    } else {
1687        Err(webx_core::error::Error::Http(format!(
1688            "Invalid URL '{}'. Use http://host:port or https://host:port",
1689            url
1690        )))
1691    }
1692}
1693
1694/// Serve plain HTTP on an already-bound listener.
1695#[allow(clippy::too_many_arguments)]
1696async fn serve_http(
1697    addr: String,
1698    listener: tokio::net::TcpListener,
1699    mut shutdown: tokio::sync::watch::Receiver<bool>,
1700    pipeline: Arc<MiddlewarePipeline>,
1701    router_handler: HandlerFn,
1702    dispatch_runtime: Arc<DispatchRuntime>,
1703    mode: AppMode,
1704    body_limits: BodyLimits,
1705    max_connections: usize,
1706) {
1707    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1708    let mut join_set = JoinSet::new();
1709
1710    loop {
1711        // Check the stored flag first: a shutdown requested before this loop
1712        // started (the documented `spawn(run); handle.shutdown()` pattern) must
1713        // not be missed.
1714        if *shutdown.borrow() {
1715            tracing::info!("HTTP {}: stop accepting, draining...", addr);
1716            break;
1717        }
1718        tokio::select! {
1719            _ = shutdown.changed() => {
1720                tracing::info!("HTTP {}: stop accepting, draining...", addr);
1721                break;
1722            }
1723            accept_result = listener.accept() => {
1724                let (stream, peer_addr) = match accept_result {
1725                    Ok(v) => v,
1726                    Err(e) => {
1727                        tracing::error!("Accept error (will retry): {}", e);
1728                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1729                        continue;
1730                    }
1731                };
1732                let remote_addr = Some(peer_addr);
1733
1734                while join_set.try_join_next().is_some() {}
1735
1736                let permit = match sem.clone().acquire_owned().await {
1737                    Ok(p) => p,
1738                    Err(_) => continue,
1739                };
1740
1741                let io = TokioIo::new(stream);
1742                let pipeline = Arc::clone(&pipeline);
1743                let router_handler = router_handler.clone();
1744                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1745                let body_limits = body_limits.clone();
1746                let conn_shutdown = shutdown.clone();
1747
1748                join_set.spawn(async move {
1749                    let _permit = permit;
1750                    let svc_fn = service_fn(move |req: Request<Incoming>| {
1751                        let pipeline = Arc::clone(&pipeline);
1752                        let router_handler = router_handler.clone();
1753                        let dispatch_runtime = Arc::clone(&dispatch_runtime);
1754                        let body_limits = body_limits.clone();
1755                        let mode = mode;
1756                        // Peer address for rate limiting and audit; captured per
1757                        // connection and carried into the request context.
1758                        let remote_addr = remote_addr;
1759                        async move {
1760                            let start = Instant::now();
1761                            let method = req.method().to_string();
1762                            let path = req.uri().path().to_string();
1763                            let result = handle_request(
1764                                req,
1765                                pipeline,
1766                                router_handler,
1767                                dispatch_runtime,
1768                                body_limits,
1769                                remote_addr,
1770                            )
1771                            .await;
1772                            let elapsed = start.elapsed();
1773                            if mode == AppMode::Development {
1774                                let status =
1775                                    result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1776                                tracing::info!(
1777                                    "[{}] {} -> {} ({:.0}ms)",
1778                                    method,
1779                                    path,
1780                                    status,
1781                                    elapsed.as_secs_f64() * 1000.0
1782                                );
1783                            }
1784                            result
1785                        }
1786                    });
1787
1788                    let conn = hyper::server::conn::http1::Builder::new()
1789                        .serve_connection(io, svc_fn);
1790                    let mut conn = std::pin::pin!(conn);
1791                    let mut conn_shutdown = conn_shutdown;
1792                    tokio::select! {
1793                        result = conn.as_mut() => {
1794                            if let Err(err) = result {
1795                                tracing::error!("Connection error: {}", err);
1796                            }
1797                        }
1798                        _ = wait_for_shutdown(&mut conn_shutdown) => {
1799                            // Let in-flight requests finish and answer with
1800                            // `Connection: close` instead of aborting the socket
1801                            // after the drain timeout.
1802                            conn.as_mut().graceful_shutdown();
1803                            if let Err(err) = conn.await {
1804                                tracing::debug!(error = %err, "connection closed during shutdown");
1805                            }
1806                        }
1807                    }
1808                });
1809            }
1810        }
1811    }
1812
1813    drain_connections(&mut join_set, &format!("HTTP {}", addr), mode).await;
1814}
1815
1816/// Serve HTTPS (TLS) on an already-bound listener.
1817#[allow(clippy::too_many_arguments)]
1818async fn serve_https(
1819    addr: String,
1820    acceptor: TlsAcceptor,
1821    listener: tokio::net::TcpListener,
1822    mut shutdown: tokio::sync::watch::Receiver<bool>,
1823    pipeline: Arc<MiddlewarePipeline>,
1824    router_handler: HandlerFn,
1825    dispatch_runtime: Arc<DispatchRuntime>,
1826    mode: AppMode,
1827    body_limits: BodyLimits,
1828    max_connections: usize,
1829) {
1830    let sem = Arc::new(tokio::sync::Semaphore::new(max_connections));
1831    let mut join_set = JoinSet::new();
1832
1833    loop {
1834        if *shutdown.borrow() {
1835            tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1836            break;
1837        }
1838        tokio::select! {
1839            _ = shutdown.changed() => {
1840                tracing::info!("HTTPS {}: stop accepting, draining...", addr);
1841                break;
1842            }
1843            accept_result = listener.accept() => {
1844                let (stream, peer_addr) = match accept_result {
1845                    Ok(v) => v,
1846                    Err(e) => {
1847                        tracing::error!("Accept error (will retry): {}", e);
1848                        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1849                        continue;
1850                    }
1851                };
1852                let remote_addr = Some(peer_addr);
1853
1854                while join_set.try_join_next().is_some() {}
1855
1856                let permit = match sem.clone().acquire_owned().await {
1857                    Ok(p) => p,
1858                    Err(_) => continue,
1859                };
1860
1861                let acceptor = acceptor.clone();
1862                let pipeline = Arc::clone(&pipeline);
1863                let router_handler = router_handler.clone();
1864                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1865                let body_limits = body_limits.clone();
1866                let conn_shutdown = shutdown.clone();
1867
1868                join_set.spawn(async move {
1869                    let _permit = permit;
1870                    match acceptor.accept(stream).await {
1871                        Ok(tls_stream) => {
1872                            let io = TokioIo::new(tls_stream);
1873                            let svc_fn = service_fn(move |req: Request<Incoming>| {
1874                                let pipeline = Arc::clone(&pipeline);
1875                                let router_handler = router_handler.clone();
1876                                let dispatch_runtime = Arc::clone(&dispatch_runtime);
1877                                let body_limits = body_limits.clone();
1878                                let mode = mode;
1879                                // Peer address for rate limiting and audit.
1880                                let remote_addr = remote_addr;
1881                                async move {
1882                                    let start = Instant::now();
1883                                    let method = req.method().to_string();
1884                                    let path = req.uri().path().to_string();
1885                                    let result = handle_request(
1886                                        req,
1887                                        pipeline,
1888                                        router_handler,
1889                                        dispatch_runtime,
1890                                        body_limits,
1891                                        remote_addr,
1892                                    )
1893                                    .await;
1894                                    let elapsed = start.elapsed();
1895                                    if mode == AppMode::Development {
1896                                        let status =
1897                                            result.as_ref().map(|r| r.status().as_u16()).unwrap_or(500);
1898                                        tracing::info!(
1899                                            "[{}] {} -> {} ({:.0}ms)",
1900                                            method,
1901                                            path,
1902                                            status,
1903                                            elapsed.as_secs_f64() * 1000.0
1904                                        );
1905                                    }
1906                                    result
1907                                }
1908                            });
1909
1910                            let conn = hyper::server::conn::http1::Builder::new()
1911                                .serve_connection(io, svc_fn);
1912                            let mut conn = std::pin::pin!(conn);
1913                            let mut conn_shutdown = conn_shutdown;
1914                            tokio::select! {
1915                                result = conn.as_mut() => {
1916                                    if let Err(err) = result {
1917                                        tracing::error!("TLS connection error: {}", err);
1918                                    }
1919                                }
1920                                _ = wait_for_shutdown(&mut conn_shutdown) => {
1921                                    conn.as_mut().graceful_shutdown();
1922                                    if let Err(err) = conn.await {
1923                                        tracing::debug!(error = %err, "TLS connection closed during shutdown");
1924                                    }
1925                                }
1926                            }
1927                        }
1928                        Err(e) => {
1929                            tracing::error!("TLS handshake error: {}", e);
1930                        }
1931                    }
1932                });
1933            }
1934        }
1935    }
1936
1937    drain_connections(&mut join_set, &format!("HTTPS {}", addr), mode).await;
1938}
1939
1940// ---------------------------------------------------------------------------
1941// TLS helpers
1942// ---------------------------------------------------------------------------
1943
1944/// Build a TLS acceptor from PEM certificate and key files.
1945fn build_tls_acceptor(cert_path: &str, key_path: &str) -> Result<TlsAcceptor> {
1946    use std::fs::File;
1947    use std::io::BufReader;
1948
1949    if cert_path.is_empty() || key_path.is_empty() {
1950        return Err(webx_core::error::Error::Http(
1951            "TLS certificate or key path not configured.".into(),
1952        ));
1953    }
1954
1955    let cert_file = File::open(cert_path).map_err(|e| {
1956        webx_core::error::Error::Http(format!("Cannot open cert '{}': {}", cert_path, e))
1957    })?;
1958    let mut cert_reader = BufReader::new(cert_file);
1959    let certs: Vec<CertificateDer> = certs(&mut cert_reader).filter_map(|r| r.ok()).collect();
1960    if certs.is_empty() {
1961        return Err(webx_core::error::Error::Http(format!(
1962            "No valid certs in '{}'",
1963            cert_path
1964        )));
1965    }
1966
1967    let key_file = File::open(key_path).map_err(|e| {
1968        webx_core::error::Error::Http(format!("Cannot open key '{}': {}", key_path, e))
1969    })?;
1970    let mut key_reader = BufReader::new(key_file);
1971    let key = pkcs8_private_keys(&mut key_reader)
1972        .filter_map(|r| r.ok())
1973        .map(PrivateKeyDer::from)
1974        .next()
1975        .or_else(|| {
1976            let key_file2 = File::open(key_path).map(BufReader::new).ok()?;
1977            let mut kr2 = key_file2;
1978            let rsa_keys: Vec<PrivateKeyDer> = rsa_private_keys(&mut kr2)
1979                .filter_map(|r| r.ok())
1980                .map(PrivateKeyDer::from)
1981                .collect();
1982            rsa_keys.into_iter().next()
1983        })
1984        .ok_or_else(|| {
1985            webx_core::error::Error::Http(format!("No valid private key in '{}'", key_path))
1986        })?;
1987
1988    let config = ServerConfig::builder()
1989        .with_no_client_auth()
1990        .with_single_cert(certs, key)
1991        .map_err(|e| webx_core::error::Error::Http(format!("TLS config error: {}", e)))?;
1992
1993    Ok(TlsAcceptor::from(std::sync::Arc::new(config)))
1994}
1995
1996#[cfg(test)]
1997mod embedded_attach_tests {
1998    use super::with_embedded_assets;
1999    use webx_spa::{ContentEncoding, EmbeddedAsset, EmbeddedAssets, SpaSource};
2000
2001    const FILES: &[EmbeddedAsset] = &[EmbeddedAsset {
2002        path: "index.html",
2003        bytes: b"hi",
2004        raw_len: 2,
2005        encoding: ContentEncoding::Identity,
2006        content_type: "text/html",
2007        etag: "\"x\"",
2008    }];
2009
2010    #[test]
2011    fn no_table_leaves_disk_source() {
2012        let source = Some(SpaSource::Disk("wwwroot".into()));
2013        let merged = with_embedded_assets(source, None);
2014        assert!(matches!(merged, Some(SpaSource::Disk(ref r)) if r == "wwwroot"));
2015    }
2016
2017    #[test]
2018    fn table_layers_under_use_spa_overlay() {
2019        let assets = EmbeddedAssets::new("../wwwroot", FILES);
2020        let merged = with_embedded_assets(Some(SpaSource::Disk("wwwroot".into())), Some(assets));
2021        match merged {
2022            Some(SpaSource::Embedded {
2023                overlay_root,
2024                assets,
2025            }) => {
2026                assert_eq!(overlay_root, "wwwroot");
2027                assert_eq!(assets.root(), "../wwwroot");
2028            }
2029            other => panic!("expected embedded, got {other:?}"),
2030        }
2031    }
2032
2033    #[test]
2034    fn table_alone_uses_build_root_as_overlay() {
2035        let assets = EmbeddedAssets::new("../wwwroot", FILES);
2036        let merged = with_embedded_assets(None, Some(assets));
2037        match merged {
2038            Some(SpaSource::Embedded { overlay_root, .. }) => {
2039                assert_eq!(overlay_root, "../wwwroot");
2040            }
2041            other => panic!("expected embedded, got {other:?}"),
2042        }
2043    }
2044
2045    #[test]
2046    fn host_crate_registers_no_asset_table() {
2047        assert!(EmbeddedAssets::registered().is_none());
2048    }
2049}