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