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